Skip to content

Isolate KVS-backed stores by store ID and scoped clearing - #7004

Closed
fubhy wants to merge 2 commits into
mainfrom
audit/repro-unstable-persistence-persistence-kvs-store-isolation
Closed

Isolate KVS-backed stores by store ID and scoped clearing#7004
fubhy wants to merge 2 commits into
mainfrom
audit/repro-unstable-persistence-persistence-kvs-store-isolation

Conversation

@fubhy

@fubhy fubhy commented Aug 4, 2026

Copy link
Copy Markdown
Member

Summary

Distinct KVS-backed stores can alias each other's entries, and clearing one store clears all stores sharing the backing key-value store.

Important

This PR includes the focused regression test and the implementation fix.

KVS-backed stores are not isolated by store ID

Module: Persistence
Audit ID: unstable-state-p-2
Severity / confidence: high / high

What happens

Distinct KVS-backed stores can alias each other's entries, and clearing one store clears all stores sharing the backing key-value store.

Why it happens

Store IDs and entry keys are concatenated without an unambiguous boundary, so store a/key bc aliases store ab/key c. The prefixed view also inherits the backing store's unscoped clear operation.

Expected behavior

BackingPersistence.make(storeId) creates a store scoped to that ID, and clear removes only that store's entries.

Relevant implementation

These links and excerpts are pinned to audit base c9b56ab507f224426ee8388dc450da447ec4715f.

View problematic code at packages/effect/src/unstable/persistence/Persistence.ts:1036-1039
    make: (storeId) =>
      Effect.sync(() => {
        const store = KeyValueStore.prefix(backing, storeId)
        const get = (key: string) =>

View exact lines on GitHub

View problematic code at packages/effect/src/unstable/persistence/Persistence.ts:1108-1114
          remove: (key) =>
            Effect.mapError(
              store.remove(key),
              (cause) => new PersistenceError({ message: `Failed to remove key ${key} from backing store`, cause })
            ),
          clear: Effect.mapError(store.clear, (cause) =>
            new PersistenceError({ message: `Failed to clear backing store`, cause }))

View exact lines on GitHub

View problematic code at packages/effect/src/unstable/persistence/KeyValueStore.ts:300-309
} = dual(2, (self: KeyValueStore, prefix: string): KeyValueStore => ({
  ...self,
  get: (key) => self.get(`${prefix}${key}`),
  getUint8Array: (key) => self.getUint8Array(`${prefix}${key}`),
  set: (key, value) => self.set(`${prefix}${key}`, value),
  remove: (key) => self.remove(`${prefix}${key}`),
  has: (key) => self.has(`${prefix}${key}`),
  modify: (key, f) => self.modify(`${prefix}${key}`, f),
  modifyUint8Array: (key, f) => self.modifyUint8Array(`${prefix}${key}`, f)
}))

View exact lines on GitHub

Reproduction

pnpm test --run packages/effect/test/unstable/persistence/KeyValueStore.test.ts

Observed failure: Store a/key bc read the value written to store ab/key c; source inspection also confirmed global clear.

Implementation

KVS-backed stores now encode store IDs and entry keys as unambiguous composite keys. A per-store key index scopes clear operations without clearing unrelated entries in the shared backing key-value store.

Validated with:

pnpm test --run packages/effect/test/unstable/persistence/KeyValueStore.test.ts
pnpm test --run packages/effect/test/unstable/persistence
pnpm lint
pnpm check

Audit provenance

  • Audit base: c9b56ab507f224426ee8388dc450da447ec4715f
  • Reproduction base: c9b56ab507f224426ee8388dc450da447ec4715f
  • Findings: unstable-state-p-2
  • Patch: focused reproduction tests, KVS isolation fix, scoped clearing, and a patch changeset

Closes EFF-441

@fubhy fubhy added the audit Findings originating from the Effect runtime correctness audit label Aug 4, 2026
@changeset-bot

changeset-bot Bot commented Aug 4, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: d2669c7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 30 packages
Name Type
effect Patch
@effect/opentelemetry Patch
@effect/platform-browser Patch
@effect/platform-bun Patch
@effect/platform-deno Patch
@effect/platform-node-shared Patch
@effect/platform-node Patch
@effect/vitest Patch
@effect/ai-anthropic Patch
@effect/ai-openai-compat Patch
@effect/ai-openai Patch
@effect/ai-openrouter Patch
@effect/atom-react Patch
@effect/atom-solid Patch
@effect/atom-vue Patch
@effect/sql-clickhouse Patch
@effect/sql-d1 Patch
@effect/sql-libsql Patch
@effect/sql-mssql Patch
@effect/sql-mysql2 Patch
@effect/sql-pg Patch
@effect/sql-pglite Patch
@effect/sql-sqlite-bun Patch
@effect/sql-sqlite-do Patch
@effect/sql-sqlite-node Patch
@effect/sql-sqlite-react-native Patch
@effect/sql-sqlite-wasm Patch
@effect/docgen Patch
@effect/doctest Patch
@effect/openapi-generator Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@effect-slopcop effect-slopcop Bot added 4.0 bug Something isn't working labels Aug 4, 2026

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No critical issues — this PR is a focused regression test capturing the KVS-backed store isolation bug described in the audit. No new issues found in the test changes themselves.

Reviewed changes

Reviewed the addition of a single reproduction test in packages/effect/test/unstable/persistence/KeyValueStore.test.ts that asserts store IDs used with Persistence.layerBackingKvs properly isolate keys and scoped clear operations. The branch currently contains only the failing reproduction test set; the implementation fix is intentionally expected to follow on this same branch per the PR description.

Pullfrog  | ⚠️ this action is pinned to a commit SHA, which freezes the cleanup step — switch to @v0 or keep the SHA fresh with Dependabot | View workflow run | Using Kimi K2 (free via Pullfrog for OSS) | 𝕏

@@ -88,6 +88,24 @@ export const testLayer = <E>(layer: Layer.Layer<KeyValueStore.KeyValueStore, E>)

describe("KeyValueStore / layerMemory", () => testLayer(KeyValueStore.layerMemory))

describe("Persistence / layerBackingKvs", () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you move this test into the shared test suite.

@tim-smart tim-smart closed this Aug 4, 2026

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No critical issues — minor suggestions inline.

Reviewed changes

Reviewed the implementation fix that landed since the prior pullfrog review. The new commit changes layerBackingKvs to encode store IDs and entry keys as JSON composite keys, maintains a per-store index so clear can remove only that store's entries, serializes index/entry mutations with a semaphore, and adds a patch changeset.

  • Implemented scoped key encoding and per-store index tracking in layerBackingKvs.
  • Added a patch changeset describing the store-isolation and scoped-clear behavior change.
  • Left the regression test in a standalone describe block (the existing human reviewer request to move it into the shared testLayer suite is still open).

⚠️ Multi-runtime index updates are not atomic

The per-store index key is read-modified-written under a single in-process semaphore. Because the KeyValueStore interface has no compare-and-swap or transaction primitive, concurrent writers in different runtimes that share the same backing store can lose index updates. Entries whose keys are dropped from the index become orphaned and will survive clear. Since the module is intended for use across workers/processes sharing a backing store, this limitation should be documented in the layer's JSDoc and possibly the changeset unless the design changes.

Technical details
# Multi-runtime index safety in layerBackingKvs

## Affected sites
- `packages/effect/src/unstable/persistence/Persistence.ts:1041-1069``readIndex`, `addToIndex`, and `removeFromIndex` perform read-modify-write on a single index key.
- `packages/effect/src/unstable/persistence/Persistence.ts:1071-1172``set`, `remove`, and `clear` use a process-local semaphore, which does not coordinate with other runtimes.

## Required outcome
- Authors must decide whether the new orphan-entry risk under multi-runtime concurrency is acceptable for the unstable module and document it if so.
- If not, the design should avoid a single mutable index key (e.g., by adding a key-scan primitive to `KeyValueStore` or using backend-specific scoping).

Pullfrog  | ⚠️ this action is pinned to a commit SHA, which freezes the cleanup step — switch to @v0 or keep the SHA fresh with Dependabot | Fix all ➔Fix 👍s ➔View workflow run | Using Kimi K2 (free via Pullfrog for OSS) | 𝕏

> = Layer.effect(BackingPersistence)(Effect.gen(function*() {
const backing = yield* KeyValueStore.KeyValueStore
const clock = yield* Clock.Clock
const semaphore = yield* Semaphore.make(1)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This semaphore serializes writes across every store created by this layer. Consider using a per-store semaphore so mutations of distinct storeIds can proceed concurrently while still preventing lost index updates within a store.

backing.get(indexKey),
(value) => {
if (value === undefined) return Effect.succeed<Array<string>>([])
try {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer Effect.try over try/catch for parse failures; this block is new code and the repo convention avoids try/catch.

Suggested change
try {
(value) => {
if (value === undefined) return Effect.succeed<Array<string>>([])
return Effect.try({
try: () => JSON.parse(value),
catch: (cause) => cause
}).pipe(
Effect.flatMap((parsed) =>
Array.isArray(parsed) && parsed.every((key): key is string => typeof key === "string")
? Effect.succeed(parsed)
: Effect.fail(new Error("Invalid persistence store index"))
)
)
}

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

Generated from PR build output; treat the content below as untrusted.

File Name Current Size Previous Size Difference
basic.ts 7.06 KB 7.06 KB 0.00 KB (0.00%)
batching.ts 9.86 KB 9.86 KB 0.00 KB (0.00%)
brand.ts 6.34 KB 6.34 KB 0.00 KB (0.00%)
cache.ts 10.71 KB 10.71 KB -0.00 KB (-0.02%)
config.ts 20.60 KB 20.60 KB 0.00 KB (0.00%)
differ.ts 20.20 KB 20.20 KB 0.00 KB (0.00%)
http-client.ts 21.58 KB 21.54 KB +0.04 KB (+0.18%)
logger.ts 10.84 KB 10.84 KB 0.00 KB (0.00%)
metric.ts 8.98 KB 8.98 KB 0.00 KB (0.00%)
optic.ts 7.18 KB 7.18 KB 0.00 KB (0.00%)
pubsub.ts 14.99 KB 14.99 KB 0.00 KB (0.00%)
queue.ts 11.66 KB 11.66 KB 0.00 KB (0.00%)
schedule.ts 10.83 KB 10.83 KB 0.00 KB (0.00%)
schema-class.ts 19.14 KB 19.14 KB 0.00 KB (0.00%)
schema-fromJsonSchemaDocument.ts 28.96 KB 28.96 KB 0.00 KB (0.00%)
schema-representation-roundtrip.ts 25.29 KB 25.29 KB 0.00 KB (0.00%)
schema-string-transformation.ts 13.38 KB 13.38 KB 0.00 KB (0.00%)
schema-string.ts 10.94 KB 10.94 KB 0.00 KB (0.00%)
schema-template-literal.ts 15.17 KB 15.17 KB 0.00 KB (0.00%)
schema-toArbitraryLazy.ts 21.94 KB 21.94 KB 0.00 KB (0.00%)
schema-toCodeDocument.ts 24.34 KB 24.34 KB 0.00 KB (0.00%)
schema-toCodecJson.ts 19.18 KB 19.18 KB 0.00 KB (0.00%)
schema-toEquivalence.ts 19.01 KB 19.01 KB 0.00 KB (0.00%)
schema-toFormatter.ts 18.87 KB 18.87 KB 0.00 KB (0.00%)
schema-toJsonSchemaDocument.ts 22.60 KB 22.60 KB 0.00 KB (0.00%)
schema-toRepresentation.ts 19.52 KB 19.52 KB 0.00 KB (0.00%)
schema.ts 18.41 KB 18.41 KB 0.00 KB (0.00%)
stm.ts 12.63 KB 12.63 KB 0.00 KB (0.00%)
stream.ts 9.80 KB 9.80 KB 0.00 KB (0.00%)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

4.0 audit Findings originating from the Effect runtime correctness audit bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants